要做出正确的选择,我们首先得弄明白这几个概念到底是什么。
这是目前单页应用 (SPA) 最主流的渲染模式。它的工作流程大致是这样的:
<div id="app"></div>
和一些指向 CSS、JavaScript 文件的链接。#app
容器里。在这个过程中,用户会先看到一个短暂的“白屏”,直到 JavaScript 加载执行完毕,页面内容才会出现。
这其实是一种“复古”的技术,在 PHP、JSP 流行的年代,网站基本都是这么做的。
既然 CSR 和 SSR 各有优劣,我们能不能把它们的优点结合起来呢?当然可以,这就是同构渲染的魅力所在。
“同构”意味着同一套代码(比如你的 Vue 组件)既可以在服务端运行,也可以在客户端运行。它的工作流程是这样的:
简单来说,同构渲染就是 “首次加载用 SSR,后续交互用 CSR”。
特性 | 传统 SSR | CSR | 同构渲染 |
---|---|---|---|
SEO | 友好 | 不友好 | 友好 |
白屏问题 | 无 | 有 | 无 |
服务端资源 | 多 | 少 | 中 |
用户体验 | 差 | 好 | 好 |
需要注意的是,同构渲染并不能缩短可交互时间 (TTI)。因为页面虽然很快显示出来了,但仍然需要等待 JavaScript 下载并执行完毕、完成“激活”之后,才能响应用户的点击等操作。
同构渲染的核心之一,就是在服务端将 Vue 组件(本质上是虚拟 DOM)转换成 HTML 字符串。这个过程其实就是一连串的字符串拼接。
我们来看一个简单的虚拟节点 (VNode) 对象:
要把它渲染成 <div id="foo"><p>hello</p></div>
,我们可以写一个简单的递归函数:
当然,一个生产级的实现要复杂得多,需要考虑很多边界情况:
<img>
, <br>
这样的标签没有闭合标签,需要特殊处理。key
, ref
等仅用于客户端的内部属性。disabled
这样的布尔属性。<
转换成 <
,把 "
转换成 "
。对于组件的渲染,原理也很直观:执行组件的 setup
或 render
函数,得到它要渲染的子树 (subTree),也就是另一个 VNode,然后递归地去渲染这个子树。
一个关键的区别是,在服务端渲染时,数据不需要是响应式的。因为服务端渲染只是一个“一次性”的快照,不存在数据变化后更新视图的场景。这可以省去创建响应式对象的开销,提升性能。
当服务器渲染的 HTML 到达浏览器后,页面虽然可见,但却是“死的”——没有交互能力。这时,客户端的 JavaScript 就需要登场,让这个静态页面“活”过来。这个过程就是激活 (Hydration)。
激活主要做两件事:
vnode.el = element
)。@click
。Vue 的渲染器通过 hydrate
函数来执行这个过程。它会从根容器的第一个子节点开始,递归地对比 VNode 和真实 DOM。如果发现类型不匹配(比如 VNode 是 <div>
,而真实 DOM 是 <span>
),就会发出警告,这通常意味着服务端和客户端渲染的内容不一致,需要开发者去排查。
通过激活,Vue 成功地在不重新创建 DOM 的情况下,接管了整个页面,为后续的客户端交互做好了准备。
既然同一套代码要跑在两个不同的环境(Node.js 和浏览器),我们在写代码时就需要特别注意一些事情。
在服务端,组件的渲染流程是“即时”的,不会有挂载到真实 DOM、更新、卸载等过程。因此,只有 beforeCreate
和 created
这两个钩子会在服务端执行。
像 mounted
, updated
, beforeUnmount
等钩子都只会在客户端执行。
一个常见的错误是在 created
里设置定时器 setInterval
,却没有在 beforeUnmount
里清除。这在服务端会造成内存泄漏,因为 beforeUnmount
永远不会被调用。
正确做法:将这类只应在客户端执行的逻辑(如 DOM 操作、定时器、事件监听)放到 onMounted
钩子中。
避免直接使用平台特有的全局变量,比如浏览器的 window
、document
,或者 Node.js 的 process
。
如果必须使用,可以用构建工具提供的环境变量(如 Vite 的 import.meta.env.SSR
)来做判断:
对于网络请求等常见需求,最好选择像 Axios 这样本身就支持跨平台的库。
在服务器上,多个用户的请求会共享同一个 Node.js 进程。如果你在组件的顶层作用域定义了一个变量,它就会变成一个“单例”状态,被所有请求共享,从而导致一个用户的数据泄露给另一个用户。
错误示例:
正确做法:始终遵循 “一个请求,一个应用实例” 的原则。将状态封装在组件实例内部(比如 data
选项或 setup
函数中),不要使用模块级的可变变量来存储请求相关的状态。
<ClientOnly>
组件有时候,你会用到一些完全不兼容 SSR 的第三方组件。这时,我们可以用一个 <ClientOnly>
组件把它包裹起来,让它只在客户端渲染。
<ClientOnly>
的实现原理很简单,它利用了 onMounted
钩子只在客户端执行的特性。在服务端,它什么都不渲染;在客户端,它等到组件挂载后,再将插槽里的内容渲染出来。